agentmux_srv\backend\storage/
migrations.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! SQL schema setup for Store, FileStore, and the saga log.
5//!
6//! `objects.db` uses a **flat schema**: `run_object_schema` defines the
7//! final table set directly in one idempotent `CREATE TABLE IF NOT EXISTS`
8//! batch. It replaced an 11-step incremental migration chain
9//! (`run_forge_v1` … `run_forge_v11`) — see
10//! `docs/specs/SPEC_SCHEMA_FLATTENING_2026_05_19.md`. The chain was pure
11//! historical accretion: per-version data dirs mean every new version is
12//! born with a fresh `objects.db` and ran the whole chain top-to-bottom
13//! anyway, so the intermediate states were never reachable in production.
14//!
15//! `filestore.db` and `sagas.db` were already single-DDL stores; they keep
16//! their existing schema functions and gain only the `user_version`
17//! tripwire (`stamp_and_check_version`).
18
19use rusqlite::Connection;
20use tracing::warn;
21
22use super::error::StoreError;
23
24/// `user_version` value stamped into `objects.db` after `run_object_schema`.
25/// The flat schema reset the counter to 1 (the pre-flatten chain never set
26/// `user_version`, so legacy files read 0). Bumped per additive migration:
27///   v1 — flat schema baseline
28///   v2 — db_agent_definitions.updated_at
29///   v3 — db_agent_definitions.user_hidden (Phase 2 hide templates,
30///        SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md Q2 Decision Y)
31///   v4 — db_agents consolidation table (Phase 3a; dual-write only,
32///        reads still on db_agent_definitions / db_agent_instances)
33///   v5 — db_agents.last_block_id (Phase 3c; latest launch's block, so the
34///        consolidated read can find the session snapshot without joining
35///        db_agent_instances)
36///   v6 — container_image / container_volumes / container_name on both
37///        db_agent_definitions and db_agents (Phase 0 of
38///        SPEC_CONTAINER_PANE_SUPPORT_2026_06_11.md; host agents default
39///        to '' / '[]' / '')
40///   v7 — db_muxbus_credentials: global singleton for MuxBus cloud
41///        Cognito PKCE tokens (access, refresh, id) + expiry + user email
42///   v8 — db_memory_bundles.is_global: global-tier flag for Trust Center
43///        bundles injected into every agent's CLAUDE.md at launch
44///   v9 — db_memory_bundles.sort_order: explicit ordering for the Trust
45///        Center global brain (controls CLAUDE.md injection order). Existing
46///        rows default to 0; the Brain tab assigns positions via reorder.
47pub const OBJECT_SCHEMA_VERSION: i64 = 9;
48/// `user_version` value stamped into `filestore.db`.
49pub const FILESTORE_SCHEMA_VERSION: i64 = 1;
50/// `user_version` value stamped into `sagas.db`.
51pub const SAGA_LOG_SCHEMA_VERSION: i64 = 1;
52
53/// Object type table names matching the `db_<otype>` convention.
54const WSTORE_OTYPES: &[&str] = &[
55    "client",
56    "window",
57    "workspace",
58    "tab",
59    "layout",
60    "block",
61    "temp",
62];
63
64/// Legacy `objects.db` table names retired by the de-forge rename, paired
65/// with their replacement. `adopt_legacy_table_names` renames any of these
66/// it finds — the single surviving piece of the old migration chain (it
67/// also subsumes the v11 `db_identities`/`db_memories` rename).
68const LEGACY_TABLE_RENAMES: &[(&str, &str)] = &[
69    ("db_forge_agents", "db_agent_definitions"),
70    ("db_forge_content", "db_agent_content"),
71    ("db_forge_skills", "db_agent_skills"),
72    ("db_forge_history", "db_agent_history"),
73    ("db_forge_agent_identities", "db_agent_identity_links"),
74    ("db_identities", "db_identity_bundles"),
75    ("db_memories", "db_memory_bundles"),
76];
77
78/// Legacy index names that must be dropped after their table is renamed —
79/// `ALTER TABLE … RENAME` keeps indexes attached but under their old names,
80/// which would collide with the flat DDL's `CREATE INDEX`. The flat DDL
81/// recreates each under the new name.
82const LEGACY_INDEX_DROPS: &[&str] = &[
83    "idx_forge_agents_slug",
84    "idx_forge_history_agent_date",
85    "idx_forge_agent_identities_account",
86    "idx_identities_is_blank",
87    "idx_memories_is_blank",
88];
89
90/// Tables retained by the old chain only for a downgrade path the flatten
91/// abandons. Dropped from any legacy DB by the adopt step; never created
92/// by the flat schema. `db_workflow_*` data was already copied into
93/// `db_drone_*` by the old v10 migration, so dropping loses nothing.
94const DEAD_TABLE_DROPS: &[&str] = &[
95    "db_workflow_definitions",
96    "db_workflow_runs",
97    "db_v10_migrated_legacy_defs",
98    "db_v10_migrated_legacy_runs",
99];
100
101/// Initialize (or re-validate) the full `objects.db` schema.
102///
103/// Idempotent — safe on every srv startup. Steps:
104///
105/// 1. `adopt_legacy_table_names` — renames any pre-flatten forge/bundle
106///    tables found (protects dev databases created before the flatten;
107///    see the spec §3/§7) and drops the dead workflow/sentinel tables.
108/// 2. The flat `CREATE TABLE IF NOT EXISTS` batch — the canonical schema.
109/// 3. Seeds the blank Identity / Memory singleton rows.
110///
111/// A database stuck at a pre-v11 intermediate schema cannot be fully
112/// adopted (its tables predate later columns). The adopt step still
113/// renames what it finds; the first query referencing a missing column
114/// then fails loudly with `no such column` — a hard error, not silent
115/// empty state, with the data preserved on disk. Per-version data dirs
116/// make that case unreachable for released builds.
117pub fn run_object_schema(conn: &Connection) -> Result<(), StoreError> {
118    adopt_legacy_table_names(conn)?;
119
120    // ---- Generic StoreObj object tables ----
121    for otype in WSTORE_OTYPES {
122        conn.execute_batch(&format!(
123            "CREATE TABLE IF NOT EXISTS db_{otype} (
124                oid     TEXT PRIMARY KEY,
125                version INTEGER NOT NULL DEFAULT 1,
126                data    TEXT NOT NULL
127            );"
128        ))?;
129    }
130
131    // ---- Agent + identity + memory + drone schema ----
132    conn.execute_batch(
133        "CREATE TABLE IF NOT EXISTS db_agent_definitions (
134            id                   TEXT PRIMARY KEY,
135            slug                 TEXT NOT NULL DEFAULT '',
136            name                 TEXT NOT NULL,
137            icon                 TEXT NOT NULL DEFAULT '✦',
138            provider             TEXT NOT NULL,
139            description          TEXT NOT NULL DEFAULT '',
140            working_directory    TEXT NOT NULL DEFAULT '',
141            shell                TEXT NOT NULL DEFAULT '',
142            provider_flags       TEXT NOT NULL DEFAULT '',
143            auto_start           INTEGER NOT NULL DEFAULT 0,
144            restart_on_crash     INTEGER NOT NULL DEFAULT 0,
145            idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
146            agent_type           TEXT NOT NULL DEFAULT 'standalone',
147            environment          TEXT NOT NULL DEFAULT '',
148            agent_bus_id         TEXT NOT NULL DEFAULT '',
149            is_seeded            INTEGER NOT NULL DEFAULT 0,
150            accounts             TEXT NOT NULL DEFAULT '',
151            parent_id            TEXT NOT NULL DEFAULT '',
152            branch_label         TEXT NOT NULL DEFAULT '',
153            created_at           INTEGER NOT NULL DEFAULT 0,
154            updated_at           INTEGER NOT NULL DEFAULT 0,
155            user_hidden          INTEGER NOT NULL DEFAULT 0,
156            container_image      TEXT NOT NULL DEFAULT '',
157            container_volumes    TEXT NOT NULL DEFAULT '[]',
158            container_name       TEXT NOT NULL DEFAULT ''
159        );
160        CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_definitions_slug
161            ON db_agent_definitions(slug);
162
163        CREATE TABLE IF NOT EXISTS db_agent_content (
164            agent_id     TEXT NOT NULL,
165            content_type TEXT NOT NULL,
166            content      TEXT NOT NULL DEFAULT '',
167            updated_at   INTEGER NOT NULL DEFAULT 0,
168            PRIMARY KEY (agent_id, content_type),
169            FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
170        );
171
172        CREATE TABLE IF NOT EXISTS db_agent_skills (
173            id          TEXT PRIMARY KEY,
174            agent_id    TEXT NOT NULL,
175            name        TEXT NOT NULL,
176            trigger     TEXT NOT NULL DEFAULT '',
177            skill_type  TEXT NOT NULL DEFAULT 'prompt',
178            description TEXT NOT NULL DEFAULT '',
179            content     TEXT NOT NULL DEFAULT '',
180            created_at  INTEGER NOT NULL DEFAULT 0,
181            FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
182        );
183
184        CREATE TABLE IF NOT EXISTS db_agent_history (
185            id           INTEGER PRIMARY KEY AUTOINCREMENT,
186            agent_id     TEXT NOT NULL,
187            session_date TEXT NOT NULL,
188            entry        TEXT NOT NULL,
189            timestamp    INTEGER NOT NULL DEFAULT 0,
190            FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
191        );
192        CREATE INDEX IF NOT EXISTS idx_agent_history_agent_date
193            ON db_agent_history(agent_id, session_date);
194
195        CREATE TABLE IF NOT EXISTS db_identity_accounts (
196            id           TEXT PRIMARY KEY,
197            name         TEXT NOT NULL,
198            provider     TEXT NOT NULL,
199            kind         TEXT NOT NULL,
200            display_name TEXT NOT NULL DEFAULT '',
201            secret_ref   TEXT NOT NULL,
202            context      TEXT NOT NULL DEFAULT '{}',
203            status       TEXT NOT NULL DEFAULT 'unknown',
204            created_at   INTEGER NOT NULL DEFAULT 0,
205            updated_at   INTEGER NOT NULL DEFAULT 0
206        );
207        CREATE INDEX IF NOT EXISTS idx_identity_accounts_provider
208            ON db_identity_accounts(provider);
209
210        CREATE TABLE IF NOT EXISTS db_agent_identity_links (
211            agent_id   TEXT NOT NULL,
212            account_id TEXT NOT NULL,
213            provider   TEXT NOT NULL,
214            PRIMARY KEY (agent_id, provider),
215            FOREIGN KEY (agent_id)   REFERENCES db_agent_definitions(id) ON DELETE CASCADE,
216            FOREIGN KEY (account_id) REFERENCES db_identity_accounts(id) ON DELETE CASCADE
217        );
218        CREATE INDEX IF NOT EXISTS idx_agent_identity_links_account
219            ON db_agent_identity_links(account_id);
220
221        CREATE TABLE IF NOT EXISTS db_identity_bundles (
222            id          TEXT PRIMARY KEY,
223            name        TEXT NOT NULL UNIQUE,
224            description TEXT NOT NULL DEFAULT '',
225            is_blank    INTEGER NOT NULL DEFAULT 0,
226            created_at  INTEGER NOT NULL DEFAULT 0,
227            updated_at  INTEGER NOT NULL DEFAULT 0
228        );
229        CREATE INDEX IF NOT EXISTS idx_identity_bundles_is_blank
230            ON db_identity_bundles(is_blank);
231
232        CREATE TABLE IF NOT EXISTS db_identity_bindings (
233            identity_id TEXT NOT NULL,
234            provider    TEXT NOT NULL,
235            account_id  TEXT NOT NULL,
236            PRIMARY KEY (identity_id, provider),
237            FOREIGN KEY (identity_id) REFERENCES db_identity_bundles(id)  ON DELETE CASCADE,
238            FOREIGN KEY (account_id)  REFERENCES db_identity_accounts(id) ON DELETE CASCADE
239        );
240        CREATE INDEX IF NOT EXISTS idx_identity_bindings_account
241            ON db_identity_bindings(account_id);
242
243        CREATE TABLE IF NOT EXISTS db_memory_bundles (
244            id            TEXT PRIMARY KEY,
245            name          TEXT NOT NULL UNIQUE,
246            description   TEXT NOT NULL DEFAULT '',
247            is_blank      INTEGER NOT NULL DEFAULT 0,
248            is_global     INTEGER NOT NULL DEFAULT 0,
249            provider      TEXT NOT NULL DEFAULT '',
250            model         TEXT NOT NULL DEFAULT '',
251            instructions  TEXT NOT NULL DEFAULT '',
252            context_files TEXT NOT NULL DEFAULT '[]',
253            mcp_servers   TEXT NOT NULL DEFAULT '[]',
254            skills        TEXT NOT NULL DEFAULT '[]',
255            sort_order    INTEGER NOT NULL DEFAULT 0,
256            created_at    INTEGER NOT NULL DEFAULT 0,
257            updated_at    INTEGER NOT NULL DEFAULT 0
258        );
259        CREATE INDEX IF NOT EXISTS idx_memory_bundles_is_blank
260            ON db_memory_bundles(is_blank);
261
262        CREATE TABLE IF NOT EXISTS db_agent_instances (
263            id                 TEXT PRIMARY KEY,
264            definition_id      TEXT NOT NULL,
265            parent_instance_id TEXT NOT NULL DEFAULT '',
266            block_id           TEXT NOT NULL DEFAULT '',
267            session_id         TEXT NOT NULL DEFAULT '',
268            status             TEXT NOT NULL DEFAULT 'running',
269            github_context     TEXT NOT NULL DEFAULT '',
270            identity_id        TEXT NOT NULL DEFAULT '',
271            memory_id          TEXT NOT NULL DEFAULT '',
272            instance_name      TEXT NOT NULL DEFAULT '',
273            working_directory  TEXT NOT NULL DEFAULT '',
274            display_hidden     INTEGER NOT NULL DEFAULT 0,
275            started_at         INTEGER NOT NULL DEFAULT 0,
276            ended_at           INTEGER NOT NULL DEFAULT 0,
277            created_at         INTEGER NOT NULL DEFAULT 0,
278            FOREIGN KEY (definition_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
279        );
280        CREATE INDEX IF NOT EXISTS idx_agent_instances_definition
281            ON db_agent_instances(definition_id);
282        CREATE INDEX IF NOT EXISTS idx_agent_instances_block
283            ON db_agent_instances(block_id);
284        CREATE INDEX IF NOT EXISTS idx_agent_instances_status
285            ON db_agent_instances(status);
286        CREATE INDEX IF NOT EXISTS idx_agent_instances_parent
287            ON db_agent_instances(parent_instance_id);
288        CREATE INDEX IF NOT EXISTS idx_agent_instances_name_recent
289            ON db_agent_instances(instance_name, started_at DESC)
290            WHERE display_hidden = 0 AND instance_name != '';
291
292        -- Phase 3a consolidation: `db_agents` collapses `db_agent_definitions`
293        -- + `db_agent_instances` into one table per
294        -- `docs/specs/SPEC_AGENT_CONCEPT_CONSOLIDATION_2026_05_24.md`.
295        -- WRITE-ONLY in 3a: every old-table mutation dual-writes here, but
296        -- every read still hits the old tables. Phase 3b migrates readers,
297        -- Phase 3c drops the old tables. Column names align with the live
298        -- `db_agent_definitions` shape (provider, working_directory, …) —
299        -- NOT the inline draft in the spec (provider_id, cmd, cmd_args, …) —
300        -- because the existing storage layer never grew the cmd-template
301        -- columns the spec sketched; carrying the names we actually have
302        -- avoids inventing data we don't store. See the PR body for the
303        -- field-by-field mapping.
304        CREATE TABLE IF NOT EXISTS db_agents (
305            id                   TEXT PRIMARY KEY,
306            name                 TEXT NOT NULL,
307            icon                 TEXT NOT NULL DEFAULT '',
308            description          TEXT NOT NULL DEFAULT '',
309
310            -- Template vs user agent
311            is_template          INTEGER NOT NULL DEFAULT 0,
312            parent_template_id   TEXT NOT NULL DEFAULT '',
313
314            -- Provider/cmd config (was on definition; named to match the
315            -- live `db_agent_definitions` columns).
316            provider             TEXT NOT NULL,
317            provider_flags       TEXT NOT NULL DEFAULT '',
318            shell                TEXT NOT NULL DEFAULT '',
319            environment          TEXT NOT NULL DEFAULT '',
320            agent_type           TEXT NOT NULL DEFAULT 'standalone',
321            agent_bus_id         TEXT NOT NULL DEFAULT '',
322            accounts             TEXT NOT NULL DEFAULT '',
323            auto_start           INTEGER NOT NULL DEFAULT 0,
324            restart_on_crash     INTEGER NOT NULL DEFAULT 0,
325            idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
326            slug                 TEXT NOT NULL DEFAULT '',
327            branch_label         TEXT NOT NULL DEFAULT '',
328
329            -- Bindings (was on instance — only meaningful when is_template=0).
330            -- For template rows these stay empty.
331            identity_id          TEXT NOT NULL DEFAULT '',
332            memory_id            TEXT NOT NULL DEFAULT '',
333            working_directory    TEXT NOT NULL DEFAULT '',
334            github_context       TEXT NOT NULL DEFAULT '',
335            instance_name        TEXT NOT NULL DEFAULT '',
336
337            -- Latest launch's block (Phase 3c): pointer to the most-recent
338            -- session's block so the consolidated read can locate the
339            -- conversation snapshot without joining db_agent_instances. The
340            -- only transient per-launch field db_agents retains; the rest
341            -- (status/session_id/started_at/ended_at) live on the block and
342            -- retire with db_agent_instances.
343            last_block_id        TEXT NOT NULL DEFAULT '',
344
345            -- Provenance
346            created_at           INTEGER NOT NULL DEFAULT 0,
347            updated_at           INTEGER NOT NULL DEFAULT 0,
348            is_seeded            INTEGER NOT NULL DEFAULT 0,
349            user_hidden          INTEGER NOT NULL DEFAULT 0,
350
351            -- Container support (Schema v6 / Phase 0).
352            -- Empty for host agents; populated by ContainerManager.
353            container_image      TEXT NOT NULL DEFAULT '',
354            container_volumes    TEXT NOT NULL DEFAULT '[]',
355            container_name       TEXT NOT NULL DEFAULT ''
356        );
357        CREATE INDEX IF NOT EXISTS idx_agents_is_template
358            ON db_agents(is_template);
359        CREATE INDEX IF NOT EXISTS idx_agents_parent_template_id
360            ON db_agents(parent_template_id);
361        CREATE INDEX IF NOT EXISTS idx_agents_is_seeded
362            ON db_agents(is_seeded);
363
364        CREATE TABLE IF NOT EXISTS db_drone_definitions (
365            id          TEXT PRIMARY KEY,
366            name        TEXT NOT NULL,
367            description TEXT NOT NULL DEFAULT '',
368            graph       TEXT NOT NULL DEFAULT '{\"nodes\":[],\"edges\":[]}',
369            viewport    TEXT NOT NULL DEFAULT '{\"x\":0,\"y\":0,\"zoom\":1}',
370            created_at  INTEGER NOT NULL DEFAULT 0,
371            updated_at  INTEGER NOT NULL DEFAULT 0
372        );
373        CREATE INDEX IF NOT EXISTS idx_drone_definitions_updated
374            ON db_drone_definitions(updated_at DESC);
375
376        CREATE TABLE IF NOT EXISTS db_drone_runs (
377            id           TEXT PRIMARY KEY,
378            drone_id     TEXT NOT NULL,
379            status       TEXT NOT NULL DEFAULT 'running',
380            started_at   INTEGER NOT NULL DEFAULT 0,
381            ended_at     INTEGER NOT NULL DEFAULT 0,
382            block_states TEXT NOT NULL DEFAULT '{}',
383            output       TEXT NOT NULL DEFAULT '',
384            error        TEXT NOT NULL DEFAULT '',
385            FOREIGN KEY (drone_id) REFERENCES db_drone_definitions(id) ON DELETE CASCADE
386        );
387        CREATE INDEX IF NOT EXISTS idx_drone_runs_drone_started
388            ON db_drone_runs(drone_id, started_at DESC);
389        CREATE INDEX IF NOT EXISTS idx_drone_runs_status
390            ON db_drone_runs(status);
391
392        -- v7: MuxBus cloud connectivity — global singleton PKCE token store.
393        CREATE TABLE IF NOT EXISTS db_muxbus_credentials (
394            id             TEXT PRIMARY KEY DEFAULT 'global',
395            cognito_domain TEXT NOT NULL DEFAULT '',
396            client_id      TEXT NOT NULL DEFAULT '',
397            access_token   TEXT NOT NULL DEFAULT '',
398            refresh_token  TEXT NOT NULL DEFAULT '',
399            id_token       TEXT NOT NULL DEFAULT '',
400            expires_at     INTEGER NOT NULL DEFAULT 0,
401            user_email     TEXT NOT NULL DEFAULT '',
402            user_sub       TEXT NOT NULL DEFAULT ''
403        );",
404    )?;
405
406    // ---- Additive column migrations (schema v2+) ----
407    // The flat CREATE batch above covers fresh databases. These ALTERs
408    // carry an existing database (e.g. a developer's dev DB that persists
409    // across builds) forward. Idempotent: the "duplicate column" error is
410    // swallowed. New additive columns append here + bump OBJECT_SCHEMA_VERSION.
411    //
412    // v2: db_agent_definitions.updated_at — last-modified timestamp
413    //     (created_at already existed; updates now stamp updated_at).
414    // v3: db_agent_definitions.user_hidden — per-user hide flag for
415    //     templates (Phase 2 of the two-tier picker spec, Q2 Decision Y).
416    //     Defaults to 0 (visible) for all existing rows so a migration
417    //     never silently hides previously-visible templates.
418    // v5: db_agents.last_block_id — most-recent launch's block (Phase 3c).
419    //     Defaults to '' for existing rows; the dual-write populates it on
420    //     the next launch/continuation. Read side (3b.1b) treats '' as
421    //     "no snapshot" (same as the current empty-block_id fallback).
422    // v6: Container support (Phase 0 of SPEC_CONTAINER_PANE_SUPPORT_2026_06_11.md).
423    //     container_image / container_volumes / container_name on both tables.
424    //     Host-agent rows default to '', '[]', '' respectively — ContainerManager
425    //     populates them on first container spawn.
426    // v7: create db_muxbus_credentials if it doesn't exist yet (existing DBs
427    //     won't have it since it's a new table, not an added column).
428    conn.execute_batch(
429        "CREATE TABLE IF NOT EXISTS db_muxbus_credentials (
430            id             TEXT PRIMARY KEY DEFAULT 'global',
431            cognito_domain TEXT NOT NULL DEFAULT '',
432            client_id      TEXT NOT NULL DEFAULT '',
433            access_token   TEXT NOT NULL DEFAULT '',
434            refresh_token  TEXT NOT NULL DEFAULT '',
435            id_token       TEXT NOT NULL DEFAULT '',
436            expires_at     INTEGER NOT NULL DEFAULT 0,
437            user_email     TEXT NOT NULL DEFAULT '',
438            user_sub       TEXT NOT NULL DEFAULT ''
439        )",
440    )?;
441
442    for stmt in &[
443        "ALTER TABLE db_agent_definitions ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0",
444        "ALTER TABLE db_agent_definitions ADD COLUMN user_hidden INTEGER NOT NULL DEFAULT 0",
445        "ALTER TABLE db_agents ADD COLUMN last_block_id TEXT NOT NULL DEFAULT ''",
446        "ALTER TABLE db_agent_definitions ADD COLUMN container_image TEXT NOT NULL DEFAULT ''",
447        "ALTER TABLE db_agent_definitions ADD COLUMN container_volumes TEXT NOT NULL DEFAULT '[]'",
448        "ALTER TABLE db_agent_definitions ADD COLUMN container_name TEXT NOT NULL DEFAULT ''",
449        "ALTER TABLE db_agents ADD COLUMN container_image TEXT NOT NULL DEFAULT ''",
450        "ALTER TABLE db_agents ADD COLUMN container_volumes TEXT NOT NULL DEFAULT '[]'",
451        "ALTER TABLE db_agents ADD COLUMN container_name TEXT NOT NULL DEFAULT ''",
452        "ALTER TABLE db_memory_bundles ADD COLUMN is_global INTEGER NOT NULL DEFAULT 0",
453        "ALTER TABLE db_memory_bundles ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
454    ] {
455        if let Err(e) = conn.execute_batch(stmt) {
456            let msg = e.to_string();
457            if !msg.contains("duplicate column") {
458                return Err(e.into());
459            }
460        }
461    }
462
463    // ---- Seed blank Identity / Memory singletons ----
464    // The launch UI renders these as the default option in its Identity /
465    // Memory dropdowns. Fixed ids so tests + dev seed data can hard-code
466    // references.
467    conn.execute_batch(
468        "INSERT OR IGNORE INTO db_identity_bundles
469            (id, name, description, is_blank, created_at, updated_at)
470         VALUES ('blank', '__blank__', 'No credentials — use ambient', 1, 0, 0);
471
472         INSERT OR IGNORE INTO db_memory_bundles
473            (id, name, description, is_blank, created_at, updated_at)
474         VALUES ('blank', '__blank__', 'Vanilla CLI — no instructions, no context', 1, 0, 0);",
475    )?;
476
477    Ok(())
478}
479
480/// Rename any pre-flatten `objects.db` tables to their de-forged names and
481/// drop the dead workflow/sentinel tables. Idempotent — on a fresh or
482/// already-flat database every check is a no-op.
483///
484/// This is the single surviving fragment of the old v1–v11 chain: it
485/// exists only to carry a developer's pre-flatten `objects.db` (always at
486/// the post-v11 schema, since v11 is merged) forward without data loss.
487/// SQLite ≥ 3.25 auto-updates foreign-key references in child tables when
488/// a parent table is renamed, so the agent/identity cascades survive.
489fn adopt_legacy_table_names(conn: &Connection) -> Result<(), StoreError> {
490    for (legacy, current) in LEGACY_TABLE_RENAMES {
491        let legacy_exists: i64 = conn.query_row(
492            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
493            [legacy],
494            |row| row.get(0),
495        )?;
496        if legacy_exists == 0 {
497            continue;
498        }
499        let current_exists: i64 = conn.query_row(
500            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
501            [current],
502            |row| row.get(0),
503        )?;
504        if current_exists == 1 {
505            // Both present — only reachable on a deliberate
506            // downgrade-roundtrip dev DB (flat build → pre-flatten build,
507            // which re-creates the legacy name → flat build again). The
508            // flatten abandons the downgrade path, but the legacy table
509            // may hold rows the downgraded build wrote. Do NOT drop it —
510            // that would be silent data loss (the bug class behind PR
511            // #933's Codex P1). Leave it on disk and warn loudly so the
512            // developer can recover or delete it manually.
513            warn!(
514                legacy_table = *legacy,
515                current_table = *current,
516                "objects.db has both a legacy table and its de-forged \
517                 replacement — this only happens after a downgrade to a \
518                 pre-flatten build; the legacy table is left untouched \
519                 for manual recovery and is otherwise unused",
520            );
521        } else {
522            conn.execute_batch(&format!("ALTER TABLE {legacy} RENAME TO {current};"))?;
523        }
524    }
525
526    // Drop indexes orphaned by the renames — the flat DDL recreates them
527    // under the new names.
528    for idx in LEGACY_INDEX_DROPS {
529        conn.execute_batch(&format!("DROP INDEX IF EXISTS {idx};"))?;
530    }
531
532    // Drop tables retained only for the abandoned downgrade path.
533    for table in DEAD_TABLE_DROPS {
534        conn.execute_batch(&format!("DROP TABLE IF EXISTS {table};"))?;
535    }
536
537    Ok(())
538}
539
540/// Initialize the FileStore schema. Creates the wave_file and file_data
541/// tables. Already a flat single-DDL store — unaffected by the
542/// `objects.db` flattening.
543pub fn run_filestore_migrations(conn: &Connection) -> Result<(), StoreError> {
544    conn.execute_batch(
545        "CREATE TABLE IF NOT EXISTS db_wave_file (
546            zoneid TEXT NOT NULL,
547            name TEXT NOT NULL,
548            size INTEGER NOT NULL DEFAULT 0,
549            createdts INTEGER NOT NULL DEFAULT 0,
550            modts INTEGER NOT NULL DEFAULT 0,
551            opts TEXT NOT NULL DEFAULT '{}',
552            meta TEXT NOT NULL DEFAULT '{}',
553            PRIMARY KEY (zoneid, name)
554        );
555
556        CREATE TABLE IF NOT EXISTS db_file_data (
557            zoneid TEXT NOT NULL,
558            name TEXT NOT NULL,
559            partidx INTEGER NOT NULL,
560            data BLOB NOT NULL,
561            PRIMARY KEY (zoneid, name, partidx)
562        );",
563    )?;
564    Ok(())
565}
566
567/// Initialize the saga durability schema (`saga` + `saga_step` tables and
568/// their indexes). See `docs/specs/SPEC_SAGA_DURABILITY_2026-05-01.md` §2.2.
569/// Already a flat single-DDL store.
570pub fn run_saga_log_migrations(conn: &Connection) -> Result<(), StoreError> {
571    conn.execute_batch(
572        "CREATE TABLE IF NOT EXISTS saga (
573            saga_id        INTEGER PRIMARY KEY,
574            name           TEXT NOT NULL,
575            state          TEXT NOT NULL,
576            started_at     INTEGER NOT NULL,
577            terminal_at    INTEGER,
578            failure_reason TEXT,
579            input_json     TEXT NOT NULL
580        );
581
582        CREATE TABLE IF NOT EXISTS saga_step (
583            saga_id     INTEGER NOT NULL REFERENCES saga(saga_id),
584            step_index  INTEGER NOT NULL,
585            name        TEXT NOT NULL,
586            state       TEXT NOT NULL,
587            cmd_json    TEXT NOT NULL,
588            output_json TEXT,
589            started_at  INTEGER NOT NULL,
590            ended_at    INTEGER,
591            PRIMARY KEY (saga_id, step_index)
592        );
593
594        CREATE INDEX IF NOT EXISTS saga_state_idx
595            ON saga(state) WHERE state IN ('running', 'compensating');
596        CREATE INDEX IF NOT EXISTS saga_terminal_idx
597            ON saga(terminal_at);",
598    )?;
599    Ok(())
600}
601
602/// `PRAGMA user_version` tripwire (AUDIT_SQLITE_SYSTEMS §8.5).
603///
604/// Compare the file's `user_version` to `current` and refuse to open
605/// the database if it was written by a NEWER binary.
606///
607/// This is the forward-compat **safety lock** from the channels design
608/// (`SPEC_DATA_CHANNELS_2026_05_24.md` §3.3). Within a channel,
609/// multiple released AgentMux versions share one data dir; the lock
610/// keeps an older binary from writing into a schema laid down by a
611/// newer binary. Same discipline as Chrome's profile-too-new check
612/// and Postgres's catalog version mismatch.
613///
614/// **Must be called BEFORE `run_*_schema` / `run_*_migrations`.** The
615/// `run_*` functions include mutating steps (legacy-table renames,
616/// seed inserts, `CREATE TABLE` for new tables the older binary
617/// doesn't have), so if we ran them first and only then checked the
618/// version, a downgraded binary could still alter a newer database
619/// before the error fires — breaking the "reject without touching
620/// disk" invariant the lock is meant to guarantee (codex P1 on PR
621/// #1029).
622///
623/// Read-only: only `PRAGMA user_version` query, no writes.
624/// [`stamp_version`] does the corresponding write AFTER migrations
625/// complete successfully.
626///
627/// Recovery for the user when this returns `SchemaTooNew`: upgrade
628/// AgentMux to a version ≥ `found`, or set
629/// `AGENTMUX_CHANNEL=<other>` to land in a different channel dir.
630/// The data on disk is preserved either way — this function never
631/// modifies the database on the rejected path.
632pub fn check_schema_compat(
633    conn: &Connection,
634    current: i64,
635    db_label: &str,
636) -> Result<(), StoreError> {
637    let found: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
638    if found > current {
639        warn!(
640            db = db_label,
641            found, expected = current,
642            "database user_version is newer than this build — refusing \
643             to open. Upgrade AgentMux or switch channels.",
644        );
645        return Err(StoreError::SchemaTooNew {
646            db: db_label.to_string(),
647            found,
648            expected: current,
649        });
650    }
651    Ok(())
652}
653
654/// Stamp the database's `user_version` PRAGMA to `current`. Called
655/// AFTER `run_*_schema` succeeds, paired with a prior
656/// [`check_schema_compat`] that gated the migrations on the
657/// caller-binary speaking a compatible (or newer) schema version.
658///
659/// Splitting the read from the write makes the safety-lock order
660/// explicit at every call site:
661///
662/// ```ignore
663/// check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")?;
664/// run_object_schema(&conn)?;
665/// stamp_version(&conn, OBJECT_SCHEMA_VERSION)?;
666/// ```
667pub fn stamp_version(conn: &Connection, current: i64) -> Result<(), StoreError> {
668    conn.execute_batch(&format!("PRAGMA user_version = {current};"))?;
669    Ok(())
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    /// Every table the flat `objects.db` schema must contain.
677    const EXPECTED_TABLES: &[&str] = &[
678        "db_client",
679        "db_window",
680        "db_workspace",
681        "db_tab",
682        "db_layout",
683        "db_block",
684        "db_temp",
685        "db_agent_definitions",
686        "db_agent_content",
687        "db_agent_skills",
688        "db_agent_history",
689        "db_identity_accounts",
690        "db_agent_identity_links",
691        "db_identity_bundles",
692        "db_identity_bindings",
693        "db_memory_bundles",
694        "db_agent_instances",
695        "db_agents",
696        "db_drone_definitions",
697        "db_drone_runs",
698    ];
699
700    fn table_exists(conn: &Connection, name: &str) -> bool {
701        let count: i64 = conn
702            .query_row(
703                "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1",
704                [name],
705                |row| row.get(0),
706            )
707            .unwrap();
708        count == 1
709    }
710
711    fn index_exists(conn: &Connection, name: &str) -> bool {
712        let count: i64 = conn
713            .query_row(
714                "SELECT count(*) FROM sqlite_master WHERE type='index' AND name=?1",
715                [name],
716                |row| row.get(0),
717            )
718            .unwrap();
719        count == 1
720    }
721
722    #[test]
723    fn test_object_schema_creates_all_tables_and_singletons() {
724        let conn = Connection::open_in_memory().unwrap();
725        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
726        run_object_schema(&conn).unwrap();
727
728        for table in EXPECTED_TABLES {
729            assert!(table_exists(&conn, table), "{table} should exist");
730        }
731        // De-forged + bundle indexes.
732        for idx in &[
733            "idx_agent_definitions_slug",
734            "idx_agent_history_agent_date",
735            "idx_agent_identity_links_account",
736            "idx_identity_accounts_provider",
737            "idx_identity_bundles_is_blank",
738            "idx_identity_bindings_account",
739            "idx_memory_bundles_is_blank",
740            "idx_agent_instances_definition",
741            "idx_agent_instances_name_recent",
742            "idx_agents_is_template",
743            "idx_agents_parent_template_id",
744            "idx_agents_is_seeded",
745            "idx_drone_definitions_updated",
746            "idx_drone_runs_status",
747        ] {
748            assert!(index_exists(&conn, idx), "{idx} should exist");
749        }
750
751        // Blank singletons seeded.
752        let id_blank: i64 = conn
753            .query_row(
754                "SELECT count(*) FROM db_identity_bundles WHERE id='blank' AND is_blank=1",
755                [],
756                |row| row.get(0),
757            )
758            .unwrap();
759        assert_eq!(id_blank, 1, "blank Identity singleton should be seeded");
760        let mem_blank: i64 = conn
761            .query_row(
762                "SELECT count(*) FROM db_memory_bundles WHERE id='blank' AND is_blank=1",
763                [],
764                |row| row.get(0),
765            )
766            .unwrap();
767        assert_eq!(mem_blank, 1, "blank Memory singleton should be seeded");
768    }
769
770    #[test]
771    fn test_object_schema_idempotent() {
772        let conn = Connection::open_in_memory().unwrap();
773        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
774        run_object_schema(&conn).unwrap();
775        run_object_schema(&conn).unwrap(); // second pass must not error
776
777        // Singletons stay unique.
778        let id_count: i64 = conn
779            .query_row("SELECT count(*) FROM db_identity_bundles", [], |r| r.get(0))
780            .unwrap();
781        assert_eq!(id_count, 1);
782    }
783
784    #[test]
785    fn test_object_schema_omits_dead_tables() {
786        let conn = Connection::open_in_memory().unwrap();
787        run_object_schema(&conn).unwrap();
788        for dead in DEAD_TABLE_DROPS {
789            assert!(!table_exists(&conn, dead), "{dead} must not be created");
790        }
791        // Legacy forge names are never created either.
792        for (legacy, _) in LEGACY_TABLE_RENAMES {
793            assert!(
794                !table_exists(&conn, legacy),
795                "legacy {legacy} must not be created by the flat schema"
796            );
797        }
798    }
799
800    #[test]
801    fn test_adopt_legacy_renames_forge_tables() {
802        // Simulate a pre-flatten (post-v11) dev DB: legacy forge table
803        // names + a dead workflow table, with seeded rows.
804        let conn = Connection::open_in_memory().unwrap();
805        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
806        conn.execute_batch(
807            "CREATE TABLE db_forge_agents (
808                id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
809                icon TEXT NOT NULL DEFAULT '✦', provider TEXT NOT NULL,
810                description TEXT NOT NULL DEFAULT '', working_directory TEXT NOT NULL DEFAULT '',
811                shell TEXT NOT NULL DEFAULT '', provider_flags TEXT NOT NULL DEFAULT '',
812                auto_start INTEGER NOT NULL DEFAULT 0, restart_on_crash INTEGER NOT NULL DEFAULT 0,
813                idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
814                agent_type TEXT NOT NULL DEFAULT 'standalone', environment TEXT NOT NULL DEFAULT '',
815                agent_bus_id TEXT NOT NULL DEFAULT '', is_seeded INTEGER NOT NULL DEFAULT 0,
816                accounts TEXT NOT NULL DEFAULT '',
817                parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
818                created_at INTEGER NOT NULL DEFAULT 0
819            );
820            CREATE UNIQUE INDEX idx_forge_agents_slug ON db_forge_agents(slug);
821            INSERT INTO db_forge_agents (id, slug, name, provider)
822                VALUES ('a1', 'coder', 'Coder', 'claude');
823
824            CREATE TABLE db_workflow_definitions (id TEXT PRIMARY KEY);",
825        )
826        .unwrap();
827
828        run_object_schema(&conn).unwrap();
829
830        // Renamed, data preserved.
831        assert!(table_exists(&conn, "db_agent_definitions"));
832        assert!(!table_exists(&conn, "db_forge_agents"));
833        let name: String = conn
834            .query_row(
835                "SELECT name FROM db_agent_definitions WHERE id='a1'",
836                [],
837                |r| r.get(0),
838            )
839            .unwrap();
840        assert_eq!(name, "Coder");
841        // Old index dropped, new index present.
842        assert!(!index_exists(&conn, "idx_forge_agents_slug"));
843        assert!(index_exists(&conn, "idx_agent_definitions_slug"));
844        // Dead table dropped.
845        assert!(!table_exists(&conn, "db_workflow_definitions"));
846    }
847
848    #[test]
849    fn test_adopt_legacy_is_noop_on_fresh_db() {
850        let conn = Connection::open_in_memory().unwrap();
851        run_object_schema(&conn).unwrap();
852        // Re-running schema (which re-runs adopt) on the already-flat DB
853        // leaves the de-forged tables intact and creates no legacy names.
854        run_object_schema(&conn).unwrap();
855        assert!(table_exists(&conn, "db_agent_definitions"));
856        assert!(!table_exists(&conn, "db_forge_agents"));
857    }
858
859    #[test]
860    fn test_adopt_legacy_both_tables_present_is_non_destructive() {
861        // Downgrade-roundtrip: a flat DB (db_agent_definitions) where a
862        // pre-flatten build later re-created db_forge_agents and wrote a
863        // row. The adopt step must NOT drop the legacy table — silent
864        // data loss is the bug class behind PR #933's Codex P1.
865        let conn = Connection::open_in_memory().unwrap();
866        run_object_schema(&conn).unwrap(); // creates db_agent_definitions
867        conn.execute_batch(
868            "CREATE TABLE db_forge_agents (id TEXT PRIMARY KEY, name TEXT NOT NULL);
869             INSERT INTO db_forge_agents (id, name) VALUES ('downgrade-era', 'Recover Me');",
870        )
871        .unwrap();
872
873        run_object_schema(&conn).unwrap();
874
875        // Legacy table left intact — data recoverable, not dropped.
876        assert!(table_exists(&conn, "db_forge_agents"));
877        let name: String = conn
878            .query_row(
879                "SELECT name FROM db_forge_agents WHERE id='downgrade-era'",
880                [],
881                |r| r.get(0),
882            )
883            .unwrap();
884        assert_eq!(name, "Recover Me");
885        // Flat table still present and authoritative.
886        assert!(table_exists(&conn, "db_agent_definitions"));
887    }
888
889    #[test]
890    fn test_adopt_legacy_fk_cascade_survives_rename() {
891        // A renamed parent must keep cascading into renamed children.
892        let conn = Connection::open_in_memory().unwrap();
893        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
894        conn.execute_batch(
895            "CREATE TABLE db_forge_agents (
896                id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
897                icon TEXT NOT NULL DEFAULT '✦', provider TEXT NOT NULL,
898                description TEXT NOT NULL DEFAULT '', working_directory TEXT NOT NULL DEFAULT '',
899                shell TEXT NOT NULL DEFAULT '', provider_flags TEXT NOT NULL DEFAULT '',
900                auto_start INTEGER NOT NULL DEFAULT 0, restart_on_crash INTEGER NOT NULL DEFAULT 0,
901                idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
902                agent_type TEXT NOT NULL DEFAULT 'standalone', environment TEXT NOT NULL DEFAULT '',
903                agent_bus_id TEXT NOT NULL DEFAULT '', is_seeded INTEGER NOT NULL DEFAULT 0,
904                accounts TEXT NOT NULL DEFAULT '',
905                parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
906                created_at INTEGER NOT NULL DEFAULT 0
907            );
908            CREATE TABLE db_forge_content (
909                agent_id TEXT NOT NULL, content_type TEXT NOT NULL,
910                content TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0,
911                PRIMARY KEY (agent_id, content_type),
912                FOREIGN KEY (agent_id) REFERENCES db_forge_agents(id) ON DELETE CASCADE
913            );
914            INSERT INTO db_forge_agents (id, name, provider) VALUES ('a1', 'Coder', 'claude');
915            INSERT INTO db_forge_content (agent_id, content_type, content)
916                VALUES ('a1', 'soul', 'hello');",
917        )
918        .unwrap();
919
920        run_object_schema(&conn).unwrap();
921
922        conn.execute("DELETE FROM db_agent_definitions WHERE id='a1'", [])
923            .unwrap();
924        let remaining: i64 = conn
925            .query_row(
926                "SELECT count(*) FROM db_agent_content WHERE agent_id='a1'",
927                [],
928                |r| r.get(0),
929            )
930            .unwrap();
931        assert_eq!(
932            remaining, 0,
933            "FK cascade must survive the forge→agent table rename"
934        );
935    }
936
937    #[test]
938    fn test_user_hidden_column_present_on_fresh_db() {
939        // Schema v3 (Phase 2 hide-templates) adds db_agent_definitions
940        // .user_hidden. A fresh database lands the column via the flat
941        // CREATE statement; an existing-but-stale database lands it via
942        // the additive ALTER below.
943        let conn = Connection::open_in_memory().unwrap();
944        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
945        run_object_schema(&conn).unwrap();
946
947        // Column exists with the documented default — INSERT without
948        // user_hidden must succeed and read back as 0.
949        conn.execute_batch(
950            "INSERT INTO db_agent_definitions (id, name, provider)
951             VALUES ('a-fresh', 'Fresh', 'claude');",
952        )
953        .unwrap();
954        let hidden: i64 = conn
955            .query_row(
956                "SELECT user_hidden FROM db_agent_definitions WHERE id='a-fresh'",
957                [],
958                |r| r.get(0),
959            )
960            .unwrap();
961        assert_eq!(hidden, 0);
962    }
963
964    #[test]
965    fn test_user_hidden_column_added_to_existing_db_via_alter() {
966        // Simulate an existing dev database created before Phase 2:
967        // db_agent_definitions exists but lacks the user_hidden column.
968        // run_object_schema must ALTER it in, preserving every existing
969        // row at the default 0. Idempotent on subsequent runs.
970        let conn = Connection::open_in_memory().unwrap();
971        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
972        conn.execute_batch(
973            "CREATE TABLE db_agent_definitions (
974                id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '',
975                name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '✦',
976                provider TEXT NOT NULL,
977                description TEXT NOT NULL DEFAULT '',
978                working_directory TEXT NOT NULL DEFAULT '',
979                shell TEXT NOT NULL DEFAULT '',
980                provider_flags TEXT NOT NULL DEFAULT '',
981                auto_start INTEGER NOT NULL DEFAULT 0,
982                restart_on_crash INTEGER NOT NULL DEFAULT 0,
983                idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
984                agent_type TEXT NOT NULL DEFAULT 'standalone',
985                environment TEXT NOT NULL DEFAULT '',
986                agent_bus_id TEXT NOT NULL DEFAULT '',
987                is_seeded INTEGER NOT NULL DEFAULT 0,
988                accounts TEXT NOT NULL DEFAULT '',
989                parent_id TEXT NOT NULL DEFAULT '',
990                branch_label TEXT NOT NULL DEFAULT '',
991                created_at INTEGER NOT NULL DEFAULT 0
992            );
993            INSERT INTO db_agent_definitions (id, name, provider, is_seeded)
994                VALUES ('pre-existing', 'Old Template', 'claude', 1);",
995        )
996        .unwrap();
997
998        run_object_schema(&conn).unwrap();
999        // Idempotent — second pass must not error and must not
1000        // re-default existing rows.
1001        run_object_schema(&conn).unwrap();
1002
1003        let hidden: i64 = conn
1004            .query_row(
1005                "SELECT user_hidden FROM db_agent_definitions WHERE id='pre-existing'",
1006                [],
1007                |r| r.get(0),
1008            )
1009            .unwrap();
1010        assert_eq!(
1011            hidden, 0,
1012            "ALTER must default existing rows to 0 (visible), never to 1",
1013        );
1014    }
1015
1016    #[test]
1017    fn stamp_version_writes_pragma() {
1018        let conn = Connection::open_in_memory().unwrap();
1019        stamp_version(&conn, OBJECT_SCHEMA_VERSION).unwrap();
1020        let v: i64 = conn
1021            .query_row("PRAGMA user_version", [], |r| r.get(0))
1022            .unwrap();
1023        assert_eq!(v, OBJECT_SCHEMA_VERSION);
1024    }
1025
1026    #[test]
1027    fn check_schema_compat_refuses_newer_db_without_writing() {
1028        // `SPEC_DATA_CHANNELS_2026_05_24.md` §3.3 safety lock — if the
1029        // DB on disk was stamped by a newer AgentMux binary, this one
1030        // MUST refuse to open it. The split into
1031        // check_schema_compat + stamp_version (codex P1 on #1029)
1032        // ensures the check runs BEFORE any migration side effects:
1033        // legacy-table rename + seed-insert in `run_object_schema` are
1034        // mutating, and if we ran them first we'd partially alter a
1035        // newer DB before the error fired.
1036        let conn = Connection::open_in_memory().unwrap();
1037        conn.execute_batch("PRAGMA user_version = 99;").unwrap();
1038        let err = check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")
1039            .expect_err("expected refusal");
1040        match err {
1041            StoreError::SchemaTooNew { db, found, expected } => {
1042                assert_eq!(db, "objects.db");
1043                assert_eq!(found, 99);
1044                assert_eq!(expected, OBJECT_SCHEMA_VERSION);
1045            }
1046            other => panic!("expected SchemaTooNew, got {other:?}"),
1047        }
1048        // Crucially, `check_schema_compat` made NO writes. The
1049        // `user_version` is still 99. (Before the split — when the
1050        // single function combined the check with the write — this
1051        // invariant held only by virtue of the early `return Err`
1052        // skipping the stamp; with the split the function is
1053        // structurally read-only, eliminating the risk that a
1054        // future refactor reintroduces a downgrade-corrupt path.)
1055        let v: i64 = conn
1056            .query_row("PRAGMA user_version", [], |r| r.get(0))
1057            .unwrap();
1058        assert_eq!(v, 99, "rejected DB must not have its user_version stamp overwritten");
1059    }
1060
1061    #[test]
1062    fn check_schema_compat_accepts_equal_or_lower_without_writing() {
1063        // check_schema_compat must NEVER write — it just gates
1064        // migrations. The on-disk `user_version` is untouched by it,
1065        // regardless of whether the verdict is accept or reject.
1066        // stamp_version is the only thing that writes, and only after
1067        // migrations succeed.
1068
1069        // Equal: check passes silently.
1070        let conn = Connection::open_in_memory().unwrap();
1071        conn.execute_batch(&format!("PRAGMA user_version = {};", OBJECT_SCHEMA_VERSION))
1072            .unwrap();
1073        check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db").unwrap();
1074        let v: i64 = conn
1075            .query_row("PRAGMA user_version", [], |r| r.get(0))
1076            .unwrap();
1077        assert_eq!(v, OBJECT_SCHEMA_VERSION);
1078
1079        // Lower (forward-migration path): check passes, version stays
1080        // at the OLD value — stamp_version is what bumps it after
1081        // migrations.
1082        let conn = Connection::open_in_memory().unwrap();
1083        conn.execute_batch("PRAGMA user_version = 1;").unwrap();
1084        check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db").unwrap();
1085        let v: i64 = conn
1086            .query_row("PRAGMA user_version", [], |r| r.get(0))
1087            .unwrap();
1088        assert_eq!(v, 1, "check_schema_compat must not write to user_version");
1089
1090        // Then stamp_version bumps it as the post-migration step.
1091        stamp_version(&conn, OBJECT_SCHEMA_VERSION).unwrap();
1092        let v: i64 = conn
1093            .query_row("PRAGMA user_version", [], |r| r.get(0))
1094            .unwrap();
1095        assert_eq!(v, OBJECT_SCHEMA_VERSION);
1096    }
1097
1098    #[test]
1099    fn test_filestore_migrations_idempotent() {
1100        let conn = Connection::open_in_memory().unwrap();
1101        run_filestore_migrations(&conn).unwrap();
1102        run_filestore_migrations(&conn).unwrap();
1103        assert!(table_exists(&conn, "db_wave_file"));
1104        assert!(table_exists(&conn, "db_file_data"));
1105    }
1106
1107    #[test]
1108    fn test_saga_log_migrations_idempotent() {
1109        let conn = Connection::open_in_memory().unwrap();
1110        run_saga_log_migrations(&conn).unwrap();
1111        run_saga_log_migrations(&conn).unwrap();
1112        assert!(table_exists(&conn, "saga"));
1113        assert!(table_exists(&conn, "saga_step"));
1114    }
1115}